💉 jimple
An extended version of the Jimple lib, with extra features.
🍿 Usage
If you are wondering why I built this, go to the Motivation section.
⚡️ Features
TypeScript out-of-the-box
Unfourtunately, Jimple doesn't have type declarations, but someone (*) took the time, and wrote a .d.ts
for it. The issue is that shipping a custom .d.ts
is not super straightforward, and there were a couple of references that I wanted to change (**), so I worked some (ugly) magic, and you can now import the Jimple
class from this package and it will already be typed:
import { Jimple } from '@homer0/jimple';
const container = new Jimple();
container.register({
register: (c) => {
},
});
*: For the life of me, I can't remember from where I copied the .d.ts
file. It was like more than a year ago and I couldn't find it again, sorry!! If you are the author, please let know and I'll give you credit.
**: If you were to extend the Jimple
class, all the references to the container type, inside the class, would point to the original class, not the extended one. That was the main thing I wanted to change.
try
method
This method is a shortcut of has
and get
: if the resource exists in the container, it returns it, otherwise, it returns undefined
:
const myService = container.try<MyService>('myService');
if (myService) {
}
Function shorthand
Not a lot of people are happy with OOP nowadays (not me :P), so, why not create a function alternative to create a container?
import { jimple } from '@homer0/jimple';
const container = jimple();
Provider shorthand
Yes, this exists on the original lib, I actually wrote it there, but the difference here is that it's a standalone function, instead of a static method, and it's created using the resource factory:
import { provider } from '@homer0/jimple';
export class MyService {
}
export const myService = provider((c) => {
c.set('myService', () => new MyService());
});
The generated object will look like this:
const myService = {
provider: true,
register: (c) => {
c.set('myService', () => new MyService());
},
};
Which means that you can go to the container and register it like this:
container.register(myService);
Provider creator
A provider creator is a both a provider, and a function that can create a provider.
Let's say you created a service that can be shared in multiple applications, or as multiple services in the same container, and you would want the implementation to be able to send some options/configuration to the provider, and maybe, also have a version that uses defaults:
import { provider } from '@homer0/jimple';
type Opts = {
url?: string;
};
export class MyService {
constructor({ url = 'http://localhost:3000' }: Opts) {
}
}
export const myServiceWithOptions = (options: Opts = {}) =>
provider((c) => {
c.set('myService', () => new MyService(options));
});
export const myService = myServiceWithOptions();
But that looks kind of hacky, and whoever implements it, needs to be aware of the two providers and their differences.
Here's where the providerCreator
comes in: this wrapper allows you to wrap the function that generates the provider, and its "default version" in a single provider:
import { providerCreator } from '@homer0/jimple';
type Opts = {
url?: string;
};
export class MyService {
constructor({ url = 'http://localhost:3000' }: Opts) {
}
}
export const myService = providerCreator((options: Opts = {}) => (c) => {
c.set('myService', () => new MyService(options));
});
The magic is that the return of providerCreator
is not actually a provider, but a Proxy
. If the proxy's register
function gets called, it will internally call the "creator function" without any arguments, and return the provider; but at the same time, you can also call the proxy as if it were the "creator function":
container.register(myService);
container.register(myService({ url: 'http://localhost:9000' }));
The providerCreator
is created using the resource creator factory.
Providers collection
As you probably already guessed, the idea here is to group multiple providers in a single object, and then register it as a single provider:
import { providers } from '@homer0/jimple';
import { myServiceA } from './services/a';
import { myServiceB } from './services/b';
export const services = providers({
myServiceA,
myServiceB,
});
This will generate a provider that, when registered, it will register all the providers in the collection:
container.register(services);
The providers
is created using the resources collection factory.
Extending the container and keeping the types
Since provider
, providerCreator
and providers
are standalone functions, if you were to extend the Jimple
class, the type for the container you receive on registration would be the original class, not the extended one.
The way to get around this is by "creating your own functions":
import {
Jimple,
createProvider,
createProviderCreator,
createProviders,
} from '@homer0/jimple';
export class MyContainer {}
export const provider = createProvider<MyContainer>();
export const providerCreator = createProviderCreator<MyContainer>();
export const providers = createProviders<MyContainer>();
Inject helper
The inject helper is a resource you can use when creating reusable services, and it takes care of resolving instances and injections:
Ensuring you always get an instance
Let's say that your service has a dependency on another service, and you want to give the ability to the implementation to inject it, but at the same time, if the implementation doesn't send it, you need an instance of the dependency to work with.
You could do an if
with the parameters, but after a few of them, it gets ugly, so that's why the helper has the .get
method:
type Dependencies = {
dep: string;
};
const helper = new InjectHelper<Dependencies>();
type MyServiceOptions = {
inject?: Partial<Dependencies>;
};
const myService = ({ inject = {} }: MyServiceOptions) => {
const dep = helper.get(inject, 'dep', () => 'default');
console.log('dep:', dep);
};
Resolving dependencies on the provider
Another use case when creating a reusable service, is to give the ability to the provider (creator) to specify the name of the services that should be injected from the container.
Keeping with the previous example, we could look for dep
on the container, but if the implementation used a different name? This is why we have the .resolve
method:
type Dependencies = {
dep: string;
};
const helper = new InjectHelper<Dependencies>();
type MyProviderOptions = {
services?: {
[key in keyof Dependencies]?: string;
};
};
const myProvider = providerCreator(
({ services = {} }: MyProviderOptions) =>
(container) => {
container.set('myService', () => {
const inject = helper.resolve(['dep'], container, services);
return myService({ inject });
});
},
);
For each dependency, the method will first check if a new name was specified (on services
), and try to get it with that name, otherwise, it will try to get it with the default name (dep
), and if it doesn't exist, it will just keep it as undefined
.
⚙️ Factories
The factories are in charge of creating the functions for the providers, and can be used to create other types of objects.
Resource factory
A resource is small object, with a name and a function. For example, a provider:
const resource = {
provider: true,
register: () => {},
};
The way the factory works is that it takes the function type, and returns a function that can be used to create the resource:
import { resourceFactory, type Jimple } from '@homer0/jimple';
type ActionFn = (c: Jimple) => void;
const factory = resourceFactory<ActionFn>();
const myAction = factory('action', 'fn', (c) => {
c.set('foo', 'bar');
});
And action
will look like this:
const myAction = {
action: true,
fn: (c) => {
c.set('foo', 'bar');
},
};
As you may have noticed, the factory task is just to set the type of the function so it can be later validated when creating the resource.
Like I did for provider
, you can create a function that wraps the result of the factory, and only takes the function:
import { resourceFactory, type Jimple } from '@homer0/jimple';
type ActionFn = (c: Jimple) => void;
const factory = resourceFactory<ActionFn>();
const action = (fn: ActionFn) => factory('action', 'fn', fn);
const myAction = action((c) => {
c.set('foo', 'bar');
});
Resource creator factory
In case you missed the section about provider creator, a creator is a proxy that can be used both as a resource, and a function that can create a resource.
Now, this is basically the same as the resource factory: it takes the function type, and returns a function that can be used to create the resource creator.
import { resourceCreatorFactory, type Jimple } from '@homer0/jimple';
type ActionFn = (c: Jimple) => void;
const factory = resourceCreatorFactory<ActionFn>();
const myAction = factory('action', 'fn', (name = 'foo') => (c) => {
c.set(name, 'bar');
});
Now, you can register with the default name, or with a custom one:
myAction.fn(container);
myAction('fooz')(container);
And like with providerCreator
, you can wrap the creation in a function:
import { resourceCreatorFactory, type Jimple } from '@homer0/jimple';
type ActionFn = (c: Jimple) => void;
type ActionCreatorFn = (...args: any[]) => ActionFn;
const factory = resourceCreatorFactory<ActionFn>();
const actionCreator = <CreatorFn extends ActionCreatorFn>(creator: CreatorFn) =>
factory('action', 'fn', creator);
const myAction = actionCreator((name = 'foo') => (c) => {
c.set(name, 'bar');
});
Resources collection factory
The collections are technically resources that contains other resources, and when their function gets called, it calls the function in every resource in the collection.
import { resourcesCollectionFactory, type Jimple } from '@homer0/jimple';
import { myActionA, myActionB } from './actions';
type ActionFn = (c: Jimple) => void;
const factory = resourcesCollectionFactory<'action', ActionFn>();
const myActions = factory('action', 'fn')({ myActionA, myActionB });
Just like with the others, the creation of the factory adds type validations, and returns a function to actually create collections.
But in this case, to simplify the implementation, you don't need to create an extra wrapper, you can just create the function the specifies the name and key (action
and fn
), and use its returned function to create the collections:
export const actions = factory('action', 'fn');
const myActions = actions({ myActionA, myActionB });
🤘 Development
As this project is part of the packages
monorepo, some of the tooling, like ESLint and Husky, are installed on the root's package.json
.
Tasks
Task | Description |
---|
test | Runs the unit tests. |
build | Bundles the project. |
Motivation
This used to be part of the wootils
package, my personal lib of utilities, but I decided to extract them into individual packages, as part of the packages
monorepo, and take the oportunity to migrate them to TypeScript.
I really like Jimple, I've used in countless projects, but there were customizations I wanted (like the ones in the features section) that didn't make a lot of sense in the original project, plus the factories, which I needed for my jimpex
project (jimple
+ express
).